程序启动代码做了什么?

#Ofilm

在嵌入式系统开发中,当我们编写完 main() 函数并下载程序后,MCU 是如何开始运行的?为什么变量能拥有初始值、全局变量能自动清零、函数调用不会崩溃?这一切的背后,都离不开一段关键但常被忽视的代码——程序启动代码(Startup Code)。

启动代码通常由编译器或芯片厂商提供,以汇编语言或 C 语言编写,它在 main() 函数执行之前运行,负责为 C 环境的正常运行做好一切准备。本文将详细解析启动代码的五大核心任务。

1. 异常向量表(Exception Vector Table)

异常向量表是启动代码中最先定义的部分,它位于程序存储器(Flash)的起始地址,通常是复位后 CPU 第一时间读取的数据。

__Vectors:
    DCD     __initial_sp            ; 初始化栈指针
    DCD     Reset_Handler           ; 复位中断处理程序
    DCD     NMI_Handler
    DCD     HardFault_Handler
    ; ... 其他异常

当 MCU 上电或复位时,CPU 首先从向量表中读取栈指针值,然后跳转到 Reset_Handler,启动代码的后续流程由此展开。

2. 从 Flash 拷贝 .data 段至 RAM

C 语言中,全局变量和静态变量如果被显式初始化(如 int x = 10;),其初始值必须在程序启动时可用。但由于 RAM 掉电丢失,这些值必须预先存储在 Flash 中,并在运行时复制到 RAM。

extern unsigned long _sidata;  // .data 在 Flash 中的起始地址
extern unsigned long _sdata;   // .data 在 RAM 中的起始地址
extern unsigned long _edata;   // .data 在 RAM 中的结束地址

unsigned long *src = &_sidata;
unsigned long *dst = &_sdata;

while (dst < &_edata) {
    *dst++ = *src++;
}

这一步确保了 int led_status = 1; 这样的变量在程序启动后立即具有正确的初始值。

3. 清零 .bss

未初始化的全局变量和静态变量(如 int count;)默认值为 0。它们被编译器放入 .bss 段。

extern unsigned long _sbss;     // .bss 起始地址
extern unsigned long _ebss;     // .bss 结束地址

unsigned long *dst = &_sbss;
while (dst < &_ebss) {
    *dst++ = 0;
}

若跳过此步,未初始化变量将包含 RAM 中的随机值,可能导致程序行为不可预测。

4. 设置栈指针(Stack Pointer)

栈(Stack)用于函数调用、局部变量存储、中断上下文保存等。栈指针(SP)必须在任何函数调用前正确设置

⚠️ 注意:在 ARM Cortex-M 架构中,复位后 CPU 会自动从向量表第一个字读取栈指针值,因此无需在代码中显式设置。但在某些架构(如 RISC-V 或自定义处理器)中,可能需要手动配置 SP。

5. 跳转至 main() 函数

完成上述所有初始化后,启动代码的使命即将完成。最后一步是:

bl main          ; 调用 main 函数

ldr pc, =main    ; 跳转到 main

从此,程序进入用户编写的 main() 函数,正式开始应用逻辑的执行。

🔄 后续main() 函数通常不会返回。如果返回,启动代码应包含一个无限循环或错误处理(如 while(1);),防止程序“跑飞”。

总结:启动代码的完整流程

步骤 操作 目的
1 定义异常向量表 提供中断入口和初始栈指针
2 拷贝 .data 恢复已初始化变量的值
3 清零 .bss 确保未初始化变量为 0
4 设置栈指针 为函数调用和中断提供运行环境
5 跳转到 main() 启动用户应用程序

附加说明:谁在使用启动代码?

结语

启动代码虽短,却是嵌入式系统稳定运行的基石。理解其工作原理,不仅能帮助我们更好地调试“程序无法启动”、“变量值异常”等问题,也为深入掌握 MCU 底层机制打下坚实基础。下次当你按下复位键时,不妨想一想:那段沉默的启动代码,正在默默为你铺平通往 main() 的道路。

💡 小贴士:可通过反汇编 .elf 文件或查看 startup_*.s 源码,亲眼见证启动代码的执行过程。